Skip to main content

TMGettingStarted

TableManager wraps a nested Luau table and lets you observe and mutate it by path: reads and writes go through the manager, and listeners fire for exactly the changes they care about — a single field, a whole subtree, an array, or the entire structure.

This guide walks through the minimal end-to-end setup. For deeper topics see the other guides linked at the bottom of the page.

1. Require the module and create a manager

TableManager.new(initialData, config?) takes your initial table and an optional config. The data is managed in place — manager.Raw is the very table you passed in.

local TableManager = require(Packages.TableManager)

local manager = TableManager.new({
	Player = { Name = "Alice", Health = 100 },
	Inventory = { "Sword", "Shield" },
})
Type Inference

TableManager.new is generic and infers the type of your initial data. This allows for autocompletion and type checking for certain methods and properties. Due to a bug within Luau, some of this functionality is currently disabled until the bug is fixed.

2. Reading data

Use Get with a dot-string or an array path. An empty path returns the root (the same table as manager.Raw).

manager:Get("Player.Health")          -- 100
manager:Get({ "Player", "Name" })     -- "Alice"
manager:Get("Inventory")              -- { "Sword", "Shield" }

String paths vs. array paths

Every path-taking method accepts either form, and they resolve to the same place. The choice is not purely cosmetic, though — three things differ:

  • What keys you can address. A dot-string is split on . into string segments, so it can only reach string keys, and never a key that itself contains a .. An array path holds the keys verbatim, so it is the only way to address a numeric index, a boolean key, or a string key with a dot in it:

    manager:Get({ "Inventory", 1 })        -- numeric index -> needs an array
    manager:Get({ "Config", "a.b.c" })     -- key literally named "a.b.c"
    manager:Get("Config.a.b.c")            -- WRONG: reads Config -> a -> b -> c
    
  • Type inference / linting. A string literal path lets the type solver resolve the value's type through the path, so Get/Set stay type-checked and autocompleted. An array path (or a string built at runtime) resolves to any, losing that inference. Prefer literal string paths where you want the types. (Some of this inference is currently disabled pending a Luau bug — see the tip above.)

  • Cost. Passing an array is the cheapest call — it is used as-is. A dot-string is split once and then cached by its string contents, so a repeated string ("Player.Health" in a loop, or a "Player." .. field that keeps producing the same result) is effectively free after the first use. The cost of assembling a string fresh each call is the allocation and concatenation itself, plus — when the field varies — a stream of distinct strings that each pay a one-time split and grow the cache. For dynamic paths, build an array instead. See the Performance guide.

Rule of thumb: literal string paths for readable, type-checked access; array paths for dynamic paths and non-string keys.

3. Writing data

Set writes a value at a path; Update and Increment are convenience wrappers over it. Array contents have their own methods — ArrayInsert, ArrayRemove, etc...

manager:Set("Player.Health", 80)
manager:Update("Player.Health", function(hp) return hp - 10 end)
manager:Increment("Player.Health", 5)

manager:ArrayInsert("Inventory", "Potion")   -- append
manager:ArrayInsert("Inventory", 1, "Bow")   -- insert at index 1

Paths in Set/Update/Increment (and the bulk reader GetMatching) may contain "*" wildcard segments to address every key at that level at once — e.g. manager:Increment("Players.*.Health", 5). See the Wildcards guide for the full rules.

Prefer paths over reaching into Raw

Writing through Set/ArrayInsert/etc. is what fires listeners. Mutating manager.Raw directly bypasses change detection — see the Proxies & Direct Table Access guide for when that is and isn't safe.

4. Your first listener

OnValueChange fires when the value at the specified path changes.

manager:OnValueChange("Player.Health", function(health, oldHealth)
	print("health changed:", oldHealth, "->", health)
end)

manager:Set("Player.Health", 50) -- fires: "health: 100 -> 50"

Every listener returns a Connection — hold onto it and call :Disconnect() when you no longer need it. The full listener surface (path listeners, key and array listeners, wildcards, and the global Signals) is covered in the Listeners & Fire Modes guide.

5. Cleanup

Call Destroy when a manager is no longer needed. It disconnects every listener and signal, tears down owned For*/Map* subscriptions, and releases proxies. It is idempotent.

manager:OnValueChange("Player.Health", onHealthChanged)

manager:OnDestroy(function()
	print("manager torn down")
end)

manager:Destroy()
print(TableManager.IsDestroyed(manager)) -- true
IsDestroyed

An alias manager:IsDestroyed() is provided as an alternate to TableManager.IsDestroyed(manager). It is the only safe method to use after Destroy. All other methods will error as the metatable is removed.


The config table at a glance

TableManager.new's second argument is a TableManagerConfig:

Field Purpose Default
Schema Validates the initial data's shape with a T check. See the Schema Validation guide. nil (no validation)
OnValidationFailed Observes schema failures before the constructor errors. nil
ListenerFireMode How listener callbacks are scheduled. See the Listeners & Fire Modes guide. "bindable"
SignalFireMode How the per-change Signals are scheduled. See the Listeners & Fire Modes guide. "bindable"
FlushMode Whether writes diff/fire immediately or coalesce to frame-end. See the Flushing guide. "immediate"
DuplicateReferenceMode Whether a table written to a second path shares identity or is cloned. See the Proxies guide. "allow"
EnableProxies Whether Proxy/GetProxy are available on this manager. true
IgnoredPaths Paths (and descendants) that skip all diff/event work. {}
FrozenTablesAreOpaque Whether shallowly frozen tables are also treated as opaque. See the Opaque Values guide. false

The mode defaults can be changed process-wide with TableManager.SetDefaults so you don't have to repeat them in every new call.


See also

Show raw api
{
    "functions": [],
    "properties": [],
    "types": [],
    "name": "TM Getting Started",
    "desc": "[TableManager](/api/TableManager) wraps a nested Luau table and lets you\nobserve and mutate it by path: reads and writes go through the manager, and\nlisteners fire for exactly the changes they care about — a single field, a\nwhole subtree, an array, or the entire structure.\n\nThis guide walks through the minimal end-to-end setup. For deeper topics see\nthe other guides linked at the bottom of the page.\n\n### 1. Require the module and create a manager\n\n`TableManager.new(initialData, config?)` takes your initial table and an\noptional [config](/api/TableManager#TableManagerConfig). The data is managed\nin place — `manager.Raw` is the very table you passed in.\n\n```lua\nlocal TableManager = require(Packages.TableManager)\n\nlocal manager = TableManager.new({\n\tPlayer = { Name = \"Alice\", Health = 100 },\n\tInventory = { \"Sword\", \"Shield\" },\n})\n```\n\n:::tip Type Inference\n`TableManager.new` is generic and infers the type of your initial data.\nThis allows for autocompletion and type checking for certain methods and properties.\nDue to a bug within Luau, some of this functionality is currently disabled until the bug is fixed.\n:::\n\n### 2. Reading data\n\nUse `Get` with a dot-string or an array path. An empty path returns the root\n(the same table as `manager.Raw`).\n\n```lua\nmanager:Get(\"Player.Health\")          -- 100\nmanager:Get({ \"Player\", \"Name\" })     -- \"Alice\"\nmanager:Get(\"Inventory\")              -- { \"Sword\", \"Shield\" }\n```\n\n#### String paths vs. array paths\n\nEvery path-taking method accepts either form, and they resolve to the same\nplace. The choice is not purely cosmetic, though — three things differ:\n\n- **What keys you can address.** A dot-string is split on `.` into string\n  segments, so it can *only* reach string keys, and never a key that itself\n  contains a `.`. An array path holds the keys verbatim, so it is the only way\n  to address a numeric index, a boolean key, or a string key with a dot in it:\n\n  ```lua\n  manager:Get({ \"Inventory\", 1 })        -- numeric index -> needs an array\n  manager:Get({ \"Config\", \"a.b.c\" })     -- key literally named \"a.b.c\"\n  manager:Get(\"Config.a.b.c\")            -- WRONG: reads Config -> a -> b -> c\n  ```\n\n- **Type inference / linting.** A *string literal* path lets the type solver\n  resolve the value's type through the path, so `Get`/`Set` stay type-checked\n  and autocompleted. An array path (or a string built at runtime) resolves to\n  `any`, losing that inference. Prefer literal string paths where you want the\n  types. (Some of this inference is currently disabled pending a Luau bug — see\n  the tip above.)\n\n- **Cost.** Passing an array is the cheapest call — it is used as-is. A\n  dot-string is split once and then **cached** by its string contents, so a\n  repeated string (`\"Player.Health\"` in a loop, or a `\"Player.\" .. field`\n  that keeps producing the same result) is effectively free after the first\n  use. The cost of assembling a string fresh each call is the allocation and\n  concatenation itself, plus — when the field varies — a stream of *distinct*\n  strings that each pay a one-time split and grow the cache. For dynamic paths,\n  build an array instead. See the [Performance](/api/TM%20Performance) guide.\n\nRule of thumb: **literal string paths** for readable, type-checked access;\n**array paths** for dynamic paths and non-string keys.\n\n### 3. Writing data\n\n`Set` writes a value at a path; `Update` and `Increment` are convenience\nwrappers over it. Array contents have their own methods — `ArrayInsert`,\n`ArrayRemove`, etc...\n\n```lua\nmanager:Set(\"Player.Health\", 80)\nmanager:Update(\"Player.Health\", function(hp) return hp - 10 end)\nmanager:Increment(\"Player.Health\", 5)\n\nmanager:ArrayInsert(\"Inventory\", \"Potion\")   -- append\nmanager:ArrayInsert(\"Inventory\", 1, \"Bow\")   -- insert at index 1\n```\n\nPaths in `Set`/`Update`/`Increment` (and the bulk reader `GetMatching`) may\ncontain `\"*\"` wildcard segments to address every key at that level at once —\ne.g. `manager:Increment(\"Players.*.Health\", 5)`. See the\n[Wildcards](/api/TM%20Wildcards) guide for the full rules.\n\n:::tip Prefer paths over reaching into `Raw`\nWriting through `Set`/`ArrayInsert`/etc. is what fires listeners. Mutating\n`manager.Raw` directly bypasses change detection — see the Proxies & Direct\nTable Access guide for when that is and isn't safe.\n:::\n\n### 4. Your first listener\n\n`OnValueChange` fires when the value at the specified path changes.\n\n```lua\nmanager:OnValueChange(\"Player.Health\", function(health, oldHealth)\n\tprint(\"health changed:\", oldHealth, \"->\", health)\nend)\n\nmanager:Set(\"Player.Health\", 50) -- fires: \"health: 100 -> 50\"\n```\n\nEvery listener returns a `Connection` — hold onto it and call `:Disconnect()`\nwhen you no longer need it. The full listener surface (path listeners, key and\narray listeners, wildcards, and the global Signals) is covered in the Listeners\n& Fire Modes guide.\n\n### 5. Cleanup\n\nCall `Destroy` when a manager is no longer needed. It disconnects every\nlistener and signal, tears down owned `For*`/`Map*` subscriptions, and releases\nproxies. It is idempotent.\n\n```lua\nmanager:OnValueChange(\"Player.Health\", onHealthChanged)\n\nmanager:OnDestroy(function()\n\tprint(\"manager torn down\")\nend)\n\nmanager:Destroy()\nprint(TableManager.IsDestroyed(manager)) -- true\n```\n\n:::tip IsDestroyed\nAn alias `manager:IsDestroyed()` is provided as an alternate to `TableManager.IsDestroyed(manager)`. \nIt is the only safe method to use after `Destroy`. All other methods will error as the metatable is removed.\n:::\n\n---\n### The config table at a glance\n\n`TableManager.new`'s second argument is a\n[TableManagerConfig](/api/TableManager#TableManagerConfig):\n\n| Field | Purpose | Default |\n| --- | --- | --- |\n| `Schema` | Validates the initial data's shape with a [`T`](/api/TableManager#T) check. See the Schema Validation guide. | `nil` (no validation) |\n| `OnValidationFailed` | Observes schema failures before the constructor errors. | `nil` |\n| `ListenerFireMode` | How listener callbacks are scheduled. See the Listeners & Fire Modes guide. | `\"bindable\"` |\n| `SignalFireMode` | How the per-change Signals are scheduled. See the Listeners & Fire Modes guide. | `\"bindable\"` |\n| `FlushMode` | Whether writes diff/fire immediately or coalesce to frame-end. See the Flushing guide. | `\"immediate\"` |\n| `DuplicateReferenceMode` | Whether a table written to a second path shares identity or is cloned. See the Proxies guide. | `\"allow\"` |\n| `EnableProxies` | Whether `Proxy`/`GetProxy` are available on this manager. | `true` |\n| `IgnoredPaths` | Paths (and descendants) that skip all diff/event work. | `{}` |\n| `FrozenTablesAreOpaque` | Whether shallowly frozen tables are also treated as opaque. See the Opaque Values guide. | `false` |\n\nThe mode defaults can be changed process-wide with\n[`TableManager.SetDefaults`](/api/TableManager#SetDefaults) so you don't have\nto repeat them in every `new` call.\n\n\n---\n### See also\n\n- **[TM Listeners & Fire Modes](/api/TM%20Listeners%20&%20Fire%20Modes)** — path/key/array listeners and scheduling.\n- **[TM Wildcards](/api/TM%20Wildcards)** — `\"*\"` paths for listeners, writes, and bulk reads.\n- **[TM Flushing](/api/TM%20Flushing)** — the diff-and-fire cycle behind every event.\n- **[TM Batching](/api/TM%20Batching)** — grouping many writes so events fire once.\n- **[TM Proxies & Direct Table Access](/api/TM%20Proxies%20&%20Direct%20Table%20Access)** — the proxy view and its rules.\n- **[TM Schema Validation](/api/TM%20Schema%20Validation)** — validating data shape at construction.\n- **[TM Opaque Values](/api/TM%20Opaque%20Values)** — telling the diff engine to skip certain values.\n- **[TM For & Map Reactive Views](/api/TM%20For%20&%20Map%20Reactive%20Views)** — per-item reconcilers and derived managers.\n- **[TM Performance](/api/TM%20Performance)** — the optimizations you get for free and how to leverage them.",
    "source": {
        "line": 180,
        "path": "lib/tablemanager/src/Docs/TM_Getting_Started.luau"
    }
}